一、pageExtensions
存在的意义
Next.js 的页面路由系统(无论是传统的 pages
目录还是 App Router 的 app
目录)均基于文件系统实现。其核心需求是精准识别项目中用于生成路由的 "页面组件",而 pageExtensions
配置的核心作用就是:明确告知 Next.js 哪些扩展名的文件可能是页面文件。
这一机制从根源上解决了 "如何区分业务代码与路由组件" 的问题,确保框架能高效定位路由相关文件。
二、pageExtensions
的工作原理
在 Next.js 的构建流程(next build
)中,扫描并处理页面文件是核心步骤之一(包括生成静态 HTML、服务端代码、中间件等),最终需要建立页面路径与组件的映射关系。
pageExtensions
的作用机制可概括为:
- 构建时,Next.js 会仅扫描带有指定扩展名的文件,直接过滤掉不在列表中的文件(如
.json
、.css
等) - 减少无意义的文件检查(如判断是否导出默认组件、是否符合路由规则等),降低计算成本
- 若不配置,Next.js 会对所有默认扩展名文件进行检查,可能导致构建效率下降
三、默认配置值
// packages/next/src/server/config-shared.ts
export const defaultConfig = {
...
pageExtensions: ['tsx', 'ts', 'jsx', 'js'], // pageExtensions 默认值
...
}
默认情况下,Next.js 会将 .tsx
、.ts
、.jsx
、.js
四种扩展名的文件视为潜在的页面组件。
四、源码层面的实现逻辑
1. 配置解析流程
// packages/next/src/server/config-shared.ts
export async function normalizeConfig(phase: string, config: any) {
if (typeof config === 'function') {
config = config(phase, { defaultConfig })
}
// Support `new Promise` and `async () =>` as return values of the config export
return await config
}
// packages/next/src/server/config.ts
const loadedConfig = Object.freeze(
(await normalizeConfig(
phase,
interopDefault(userConfigModule)
)) as NextConfig
)
配置解析时,pageExtensions
会与其他配置项一起被规范化处理,最终整合到 Next.js 的完整配置对象中,供后续流程使用。
2. 整合 Layout 静态信息
export async function getStaticInfoIncludingLayouts({
isInsideAppDir,
pageExtensions,
pageFilePath,
appDir,
config: nextConfig,
isDev,
page,
}: {
isInsideAppDir: boolean
pageExtensions: PageExtensions
pageFilePath: string
appDir: string | undefined
config: NextConfigComplete
isDev: boolean | undefined
page: string
}): Promise<PageStaticInfo> {
...
// inherit from layout files only if it's a page route
if (isAppPageRoute(page)) {
const layoutFiles = []
const potentialLayoutFiles = pageExtensions.map((ext) => 'layout.' + ext)
// 这里将扩展名映射为了可以支持的 layout.[ext] 文件名,例如配置tsx,jsx;那么这个集合包含了「layout.tsx, layout.jsx」
let dir = dirname(pageFilePath)
...
}
在处理 Layout 相关信息时,pageExtensions
会被用于生成可能的布局文件名称(如 layout.tsx
、layout.js
等),确保框架能正确识别布局组件。
3. 页面文件路径生成
/**
* Calculate all possible pagePaths for a given normalized pagePath along with
* allowed extensions. This can be used to check which one of the files exists
* and to debug inspected locations.
*
* For pages, map `/route` to [`/route.[ext]`, `/route/index.[ext]`]
* For app paths, map `/route/page` to [`/route/page.[ext]`] or `/route/route`
* to [`/route/route.[ext]`]
*
* @param normalizedPagePath Normalized page path (it will denormalize).
* @param extensions Allowed extensions.
*/
export function getPagePaths(
normalizedPagePath: string,
extensions: string[],
isAppDir: boolean
) {
const page = denormalizePagePath(normalizedPagePath)
let prefixes: string[]
// 两种路由模式不同的处理
/*
*如果是 app 目录,只用 page 本身作为前缀。
*如果是 pages 目录且路径以 /index 结尾,只用 page/index 作为前缀。
*否则,既用 page 也用 page/index 作为前缀。
*/
if (isAppDir) {
prefixes = [page]
} else if (normalizedPagePath.endsWith('/index')) {
prefixes = [path.join(page, 'index')]
} else {
prefixes = [page, path.join(page, 'index')]
}
const paths: string[] = []
for (const extension of extensions) {
for (const prefix of prefixes) {
paths.push(`${prefix}.${extension}`)
}
}
// ⬆️这里最后会返回包含符合pageExtensions的可能存在的页面路径数组
return paths
}
该函数根据 pageExtensions
生成所有可能的页面文件路径(如 /about.tsx
、/about/index.js
等),为后续的文件存在性检查提供候选列表。
4. 页面文件匹配与校验(核心优化逻辑)
/**
* Finds a page file with the given parameters. If the page is duplicated with
* multiple extensions it will throw, otherwise it will return the *relative*
* path to the page file or null if it is not found.
*
* @param pagesDir Absolute path to the pages folder with trailing `/pages`.
* @param normalizedPagePath The page normalized (it will be denormalized).
* @param pageExtensions Array of page extensions.
*/
export async function findPageFile(
pagesDir: string,
normalizedPagePath: string,
pageExtensions: PageExtensions,
isAppDir: boolean
): Promise<string | null> {
// 第一步这里拿到的就是 1.4.3 最后返回的符合 pageExtensions 的可能存在页面组件路径
const pagePaths = getPagePaths(normalizedPagePath, pageExtensions, isAppDir)
// 这里的判空逻辑蛮优雅的,数组解构拿到了第一项,然后下面都是一系列的验证这个可能路径上的文件是否存在,当然如果存在的话,就是页面组件了
const [existingPath, ...others] = (
await Promise.all(
pagePaths.map(async (path) => {
const filePath = join(pagesDir, path)
try {
return (await fileExists(filePath)) ? path : null
} catch (err: any) {
if (!err?.code?.includes('ENOTDIR')) throw err
}
return null
})
)
).filter(nonNullable)
if (!existingPath) {
return null
}
if (!(await isTrueCasePagePath(existingPath, pagesDir))) {
return null
}
// 在上面,我们已经排空了,那么如果同一目录下,如果出现多个pageExtensions支持的页面组件文件,
// 例如:同文件夹下存在「page.tsx, page.jsx」,那么,nextjs会直接报出下面的警告,「会有两个文件被解析成同一个路由」。
if (others.length > 0) {
warn(
`Duplicate page detected. ${cyan(join('pages', existingPath))} and ${cyan(
join('pages', others[0])
)} both resolve to ${cyan(normalizedPagePath)}.`
)
}
return existingPath
}
这是 pageExtensions
实现优化的核心逻辑:通过过滤出仅符合指定扩展名的文件,大幅减少需要检查的文件数量,同时处理了文件重复定义等异常情况。
5. 元数据路由文件匹配
// packages/next/src/lib/metadata/is-metadata-route.ts
/**
* Determine if the file is a metadata route file entry
* @param appDirRelativePath the relative file path to app/
* @param pageExtensions the js extensions, such as ['js', 'jsx', 'ts', 'tsx']
* @param strictlyMatchExtensions if it's true, match the file with page extension, otherwise match the file with default corresponding extension
* @returns if the file is a metadata route file
*/
export function isMetadataRouteFile(
appDirRelativePath: string,
pageExtensions: PageExtensions,
strictlyMatchExtensions: boolean
) {
// End with the extension or optional to have the extension
// When strictlyMatchExtensions is true, it's used for match file path;
// When strictlyMatchExtensions, the dynamic extension is skipped but
// static extension is kept, which is usually used for matching route path.
const trailingMatcher = (strictlyMatchExtensions ? '' : '?') + '$'
// Match the optional variants like /opengraph-image2, /icon-a102f4.png, etc.
const variantsMatcher = '\\d?'
// The -\w{6} is the suffix that normalized from group routes;
const groupSuffix = strictlyMatchExtensions ? '' : '(-\\w{6})?'
const suffixMatcher = `${variantsMatcher}${groupSuffix}`
// 这里生成一些元数据路由文件扩展名与 pageExtensions 中的扩展名的正则集合
const metadataRouteFilesRegex = [
new RegExp(
`^[\\\\/]robots${getExtensionRegexString(
pageExtensions.concat('txt'),
null
)}${trailingMatcher}`
),
new RegExp(
`[\\\\/]${STATIC_METADATA_IMAGES.twitter.filename}${suffixMatcher}${getExtensionRegexString(
STATIC_METADATA_IMAGES.twitter.extensions,
pageExtensions
)}${trailingMatcher}`
),
...
]
const normalizedAppDirRelativePath = normalizePathSep(appDirRelativePath)
const matched = metadataRouteFilesRegex.some((r) =>
r.test(normalizedAppDirRelativePath)
)
// 最后得到的 matched 就是匹配成功的元数据路由文件
return matched
}
在处理元数据路由(如 robots.txt
、opengraph-image.png
等)时,pageExtensions
会与元数据文件特有的扩展名结合,确保框架能正确识别这类特殊路由文件。
五、社区讨论(配置思路和可能存在的风险)
1、团队规范
两种路由模式配置相关
// ⬇️ App router 基于文件系统的约定,它并不需要进行 pageExtensions 配置
// 除非你需要使用 MDX OR 路由模式混合(同时使用app router,pages router)
module.exports = {
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"]
}
// ⬇️ Pages router
module.exports = {
pageExtensions: ['page.tsx', 'page.ts', 'page.jsx', 'page.js']
}
// 或者 区分服务端组件、客户端组件和API
module.exports = {
pageExtensions: ['server.tsx', 'client.tsx', 'api.ts']
};
2、How do I render a table with next mdx? #77961
在配置pageExtensions中加入["md", "mdx"]想要使用mdx-components时,在NextJS15的开发模式使用turbopack的情况下,会导致报错,原因看上去是turbopack与mdxRs有冲突,至今未解决的discussion.
import remarkGfm from "remark-gfm";
import createMDX from "@next/mdx";
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
};
const withMDX = createMDX({
options: {
remarkPlugins: [remarkGfm],
rehypePlugins: [],
},
});
export default withMDX(nextConfig);
六、Demo Show
项目 Demo:配置 pageExtensions 支持 mdx 页面组件 https://github.com/indulgeback/telos/commit/a781f0777289c2676bb41696850c8d55dff341ff App router 模式必须配置 mdx-components.tsx,与 /app 同级(也可在 next.config.ts 做如下配置)
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
import createMDX from "@next/mdx";
import remarkGfm from "remark-gfm";
import rehypePrismPlus from "rehype-prism-plus";
const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = {
pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"],
};
const withMDX = createMDX({
extension: /\.(md|mdx)$/,
options: {
providerImportSource: "@/components/mdx-components",
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypePrismPlus],
},
});
export default withMDX(withNextIntl(nextConfig));
参考文档
https://nextjs.org/docs/14/app/api-reference/next-config-js/pageExtensions
https://github.com/vercel/next.js/blob/canary/packages/next/src/build/entries.ts
https://github.com/vercel/next.js/blob/canary/packages/next/src/build/utils.ts
https://www.meje.dev/blog/page-extensions-in-nextjs
https://stackoverflow.com/questions/76715822/set-next-js-pageextensions-to-not-build-dev-pages